Домашняя работа №2¶

"Соревнование по классификации TinyImageNet"¶

Дмитрий Клёстов¶

1. Исходные данные¶

Датасет TinyImageNet. 120000 изображений размера 64x64, разделенные на train (100000), val и test (по 10000).

Изображения поровну распределены по 200 классам.

Требуется построить классификатор изображений из датасета на основе свёрточной нейронной сети с максимально возможной точностью - accuracy@1.

2. Работа с источниками.¶

В сети много информации по подходам к датасету, например:

  • kaggle соревнование 2019 года - лучшая точность 0.86, хотя второе место 0.46, подозрительно большой отрыв
  • отчёт из Стэнфорда 2017 года - Inception-Resnet достиг 43% error-rate. Также рассмотрены модели resnet и vgg-16/19.
  • статья про использование ViT для классификации Tiny Imagenet 2022 года - достигнута точность 91.35%. Но, т.к.требуется использовать свёрточные нейросети, эта модель останется вне нашего внимания, просто примем полученную здесь точность как некоторый идеал.

В соревновании нужно побить два бейзлайна - с точностями 0.25 и 0.5, что с учётом данных выше выглядит вполне реалистичным

3. Первые эксперименты¶

Я решил работать в основном с моделями семейства resnet, т.к.они есть в библиотеке torchvision, хорошо себя показывают в многоклассовой классификации изображений, быстро сходятся, пред'являют разумные требования по памяти, и дают возможность поэкспериментировать как с лёгкими вариантами (resnet18) так и с вариантами потяжелее (resnet152) без перекраивания архитектуры.

Первые подходы давали точность ниже 0.4. Пробовал различные версии resnet, варьировал learning_rate - точности сходились к асимптоте чуть ниже 0.4

In [11]:
import mlflow
from mlflow.tracking import MlflowClient
import plotly.graph_objects as go
import pandas as pd
import plotly.io as pio
pio.renderers.default = 'notebook'


def plot_mlflow_metrics(run_ids, metric_names=None, pt=0):
    """
    Загружает и визуализирует метрики из MLflow.

    Аргументы:
        run_ids (list[str] | str): один или несколько MLflow run_id
        metric_names (list[str], optional): какие метрики грузить
            По умолчанию: ['val acc', 'val loss', 'train acc', 'train loss']
    """
    if isinstance(run_ids, str):
        run_ids = [run_ids]

    client = MlflowClient()
    metric_names = metric_names or ['val acc', 'val loss', 'train acc', 'train loss']
    accs = [m for m in metric_names if "acc" in m]
    losses = [m for m in metric_names if "loss" in m]

    metrics = {}

    # --- Сбор данных из MLflow ---
    for ri in run_ids:
        metrics[ri] = {}
        for mn in metric_names:
            hist = client.get_metric_history(ri, mn)
            if len(hist) > 0:
                df = pd.DataFrame([{"step": m.step, "value": m.value} for m in hist])
                df = df.sort_values("step").reset_index(drop=True)
                metrics[ri][mn] = df
                # print(f"✅ Found {mn} for {ri[:8]}: {len(df)} points")
            else:
                print(f"⚠️ No data for {mn} in {ri[:8]}")

    # --- Accuracy plot ---
    fig_acc = go.Figure()
    for ri, mset in metrics.items():
        for mn in accs:
            if mn in mset:
                df = mset[mn]
                fig_acc.add_trace(go.Scatter(
                    x=df["step"],
                    y=df["value"],
                    mode="lines+markers",
                    name=f"{ri[:6]} - {mn}"
                ))

    fig_acc.update_layout(
        title="Accuracy Metrics from MLflow Runs",
        xaxis_title="Step",
        yaxis_title="Accuracy",
        template="plotly_white",
        width=900,
        height=600
    )


    # --- Loss plot ---
    fig_loss = go.Figure()
    for ri, mset in metrics.items():
        for mn in losses:
            if mn in mset:
                df = mset[mn]
                fig_loss.add_trace(go.Scatter(
                    x=df["step"],
                    y=df["value"],
                    mode="lines+markers",
                    name=f"{ri[:6]} - {mn}"
                ))

    fig_loss.update_layout(
        title="Loss Metrics from MLflow Runs",
        xaxis_title="Step",
        yaxis_title="Loss",
        template="plotly_white",
        width=900,
        height=600
    )

    fig_acc.show()
    fig_loss.show()
    
    # fig_acc.write_html(f"fig_acc_{pt}.html", include_plotlyjs='cdn', full_html=False)
    
    # fig_loss.write_html(f"fig_loss_{pt}.html", include_plotlyjs='cdn', full_html=False)

Ниже примеры кривых обучения некоторых экспериментов этого периода

In [12]:
plot_mlflow_metrics([
    '5ce61a4f14b946f5b17b3ed59a66fa61',
    'e38853ef92c343fc8169d94ed48ec931',
    'f0a0156f2ba5452e84256ef0c322f39d',
    '97cfce2484b949069f00d5a9d19d7c82'
], pt=3)

4. Эксперименты с нарушениями¶

Очень бросался в глаза пункт про запрет на upsampling входных данных. Я решил попробовать что это может дать. Точность значительно подскочила, пробив отметку в 0.5, то есть больше чем на 10%

Ниже примеры некоторых тренировок того времени

In [13]:
plot_mlflow_metrics([
    '7e28894db4f54fc69859d9c228936a78',
    '888ab91a770a4d53a8914ab1602b4509'
], pt=4)

Кроме того, раз уж мы зашли в запретную зону я попробовал что может дать self-supervised претренировка на train подмножестве. Схема такая: загружаем батч и применяем к нему две разных аугментации и даём в них лэйблы, не связанные с данными, но так, чтобы на одной и той же картинке был один и тот же лейбл. Дальше обучаем модель на минимизацию кросс-энтропии.

По идее первые слои должны выучивать особенности, и создающие инвариантность к аугментациям, но видимо из-за ошибок и отсутствия опыта в такой технике, улучшений ни в смысле скорости сходимости ни в смысле точности не получилось.

In [14]:
plot_mlflow_metrics([
    'b7f3320b03ca40b68851bf0fc8ae433d',
    '7ad88093477942839474ab34c65935c8'
], pt=4.1)

5. Эксперименты с модификацией архитектуры ResNet¶

После предыдущего пункта появилась идея заглянуть в архитектуру ResNet повнимательнее и понять, почему upsampling даёт такое преимущество. Появилась интуитивная гипотеза, что по причине маленького размера картинки модели нужно самые низкоуровневые признаки протащить поглубже. В ResNet же первый свёрточный слой имеет kernel size 7x7 и stride 2x2. А потом ещё идёт maxpooling, что даёт уже довольно низкое разрешение ко второму свёрточному слою.

Модификация заключается в том, что мы заменяем первый свёрточный слой на слой с kernel_size 3x3 и stride 1x1, а также убираем maxpooling. Тогда ко второму свёрточному слою разрешение не будет так сильно загрублено.

Также я поэкспериментировал с различными начальными (и постоянными) learning rate, и эмпирически нащупал те, которые дают комбо более быстрой сходимости и более высокой установившейся точности

Здесь же я стал использовать reduce on plateau планировщик, потому что в какой-то момент валидационный лосс начинал улетать в космос, благодаря планировщику удалось его удерживать

И это дало хороший прирост: модели стали сходиться к точностям в диапазоне 0.54-0.56, на чём я и остановился

In [15]:
plot_mlflow_metrics([
    'cb71e620a06a41b5b3fd0e04adfd9b3a',
    'c6a1c3a418d0492cb8376ed58cac5e4c',
    '30935ecaf8484365baf57751ca4149b9',
    '73ca7a48a5db4791bf0881006d413f62'
], pt=5)

Остальные (неудачные) эксперименты¶

Также были попробованы разные архитектуры из других семейств: efficientnet, mobelinet, convnext, - но до таких же значений точности как в экспериментах ResNet довести не получилось. Однако если уделить им побольше времени, то возможно что-то и получилось бы.

Ниже кривые обучения convnext small и tiny, видно сильное переобучение. Здесь использован оптимизатор AdamW и планировщик cosine annealing.

In [16]:
plot_mlflow_metrics([
    '85f495b18a2844b494cb4ba06bc17f46',
    '4a453b755ba640b98a888e6272d1b86c',
], pt=6)

6. Модификация первых слоёв и головы ResNet¶

In [ ]:
self.model.fc = nn.Sequential(nn.Dropout(0.6), nn.Linear(self.model.fc.in_features, 200))
self.model.conv1 = nn.Conv2d(3, 64, 3, stride=1, padding=1)
self.model.maxpool = nn.Identity()

7. Аугментации¶

На протяжении всех экспериментов я использовал следующие аугментации

  • RandomHorizontalFlip
  • RandomVerticalFlip
  • RandomErasing(scale=(0.02, 0.1), value='random')
  • ColorJitter(brightness=0.4, contrast=0.1)

В первых экспериментах их отсутсвие давало быстрое переобучение, тренировочная точность сходилась к 0.9, в то время как валидационная доползала до 0.3, поэтому я добавил их, получил прирост точности и более медленное переобучение, зафиксировал и больше с ними не экспериментировал.

Выводы¶

  1. Данные важны, аугментации важны, архитектуры важны
  2. Добиться эффекта от upsampling-а можно немного поменяв первые слои в архитектуре
  3. Положительные эффекты от Self Supervised предобучения не ощутились
  4. Непонятным остался метод выбора learning rate. Поскольку он влияет и на скорость сходимости и на значение, к которому сходимся, то он очень важен, но после домашки осталось ощущение, что в каждой новой задаче нужно посвящать некоторое время только его подбору.
In [ ]: